系列背景:私有自持的 AI agent 網格 spectyn-mesh。
Day 06 把單機版裝進.app,並收掉了「停止說謊」到「權限批准」六個階段 ——
收工時的帳很漂亮:+891 條測試、全量 3,528 全綠、四個 commit。然後這兩天我做了兩件事,而它們指向同一個問題:
打勾,和真的成立,是兩件事。

第一件事發生在昨天:動手之前,我先把佇列上每一項的現況實際量一次。
12 個題目,五個翻案 —— 佇列是我自己前一天寫的,而一天之後它有五格已經不對了。
第二件事發生在今天:我量了一件從來沒量過的事 —— 操作者實際會跑的那支 binary
是哪一版。答案是 8 月 28 日的。
那些修掉的謊 —— exit 0 卻什麼都沒做、doctor 謊報沒有身分、--config 被靜默
忽略、半截答案被當成完成 —— 對他的機器來說,今天全部還在。
而且沒有任何東西會告訴他:spectyn --version 印得出 commit,但從來沒有人拿它跟
repo 比對過。
母規則同前:任何檢查必須先示範它會紅才算數。這兩天各補了一條:
佇列也不是證據;以及 打了勾的出口條件,不代表有人走過它。

翻案的不是「我做錯了」。是題目是一天前寫的,而那一天之後東西動過了 ——
其中兩項在我自己前一天的修復裡就已經好了,我卻還在佇列上留著它們。
照字面往下做的代價很具體:兩項會去修沒壞的東西,一項會刪掉一道刻意的安全閘,
一項會為不存在的缺口補測試。

測量 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型,
線上餵同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。
非串流路徑算出 $7.50,ledger 有寫。 CostTracker、價格表、持久化、
四處顯示——全部正確。
壞的只有串流路徑的 usage 傳遞,而且斷在三個地方:每個 synthetic return
寫死 "usage": {};SSE 解析迴圈只看 choices / delta / type,而 usage frame
帶的是 choices: [];include_usage 只在有工具時才向供應商索取。
成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,
是連 ledger 檔都不會被建立。
如果照佇列去「修成本追蹤」,我會在一段完全正確的程式碼裡找一個不存在的 bug。
它現在拒絕,而那是刻意的:外部複查之後加的 fail-closed 閘。
照字面「修好」——把 guard 刪掉——會把 22 條未驗證的 /api 與 /rpc 路由
(/api/todos、/api/sessions、/api/cost、/api/governance/log、/rpc/peers……)
publish 到 tailnet 或區網上。
這一項我沒有動,列成裁定點交回去。這是安全決策,不是我該替操作者做的。
一個佇列項目寫成「今天拒絕」,讀起來像缺陷。實際上「拒絕」就是它的功能。
macOS 換錯目錄 和 uninstall 移不掉舊 label,測量都回報already_fixed——前者在 commit 40219efe,後者兩個 label 都處理。
照佇列做會把已經修好的程式碼改回舊 bug。
「唯一碰精靈的測試斷言精靈不要跑」 這一條更直接:題目本身是錯的。
現在有六個檔在測那個精靈,其中 onboarding_asks_before_it_acts 在真的 PTY 上
驅動它並斷言正向行為。那是前一天才補的——題目比修復舊了一天。

這是今天主題最純粹的一次。同一份內容,伺服器兩種死法:
stdout、stderr、退出碼——逐位元無法區分,兩次 stderr 都是空的。
同一個埠上 curl 說 exit 56 Connection reset by peer。
一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件,而且沒有任何東西
可以據以判斷。
原因是五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件。唯一的守衛 if full_content.is_empty(),
被一個位元組的內容解除。
修法的三個陷阱,測量都先講了:
不能「沒有 [DONE] 就算斷線」。 Anthropic 的串流就是靠 body EOF 結束的——
這個迴圈根本不解析 message_stop。所以我只標記兩個無歧義的死法:
傳輸錯誤、閒置逾時。EOF 刻意不標,理由寫進程式碼。
不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈和
多輪 agent 迴圈裡。那一輪若已發出 tool_calls,重跑會再執行一次file_write 和 shell。一次網路抖動不可以變成操作者磁碟上的重複副作用。
這段程式碼就是已安裝的 daemon 在跑的。 所以全程沒有重建到已安裝的產物。
順帶:閒置逾時從寫死 30 秒改成可設定。不是為了彈性——最常見的死法
(wifi 斷了但 socket 沒關)走的就是那條路,而沒有測試付得起半分鐘,
所以行為最誤導的那條路完全沒有覆蓋。

self-update 沒有可用來源。但比題目更糟的是——就算操作者把 R2 填滿,
CLI 要的那個名字仍然會 404。
線上伺服器把兩種失敗分得很清楚:404 是 manifest 的答案,503 才是 bucket 的。
大家會去做的那個修法(上傳 binary),根本不可能生效。而scripts/publish-mac.sh 用 OS-ARCH 名字 curl 驗證自己的上傳,會回報成功,
同時 self-update 依然壞著。
第三個獨立斷點:.sha256 在 Worker 裡出現零次。更新器沒有 digest 就
fail-closed——那個拒絕是對的,它是下載唯一的完整性檢查——所以名字對了、
bucket 填了,還是裝不起來。
修法是把 Worker 的 manifest 補上 triple 形式別名、加上 sidecar 服務,
並新增一條從 Rust 讀 TypeScript 的測試:CLI 與 install.sh 能請求的每個名字,
都必須在 manifest 裡。請求名與儲存名住在不同語言,先前沒有任何東西檢查它們一致。
危險提示也很具體:別放寬 https 守衛、別讓 sidecar 變成可選。
來源是空的,守衛是對的。
前一天我寫了一個 mDNS advertiser 的回收器,並在 commit 訊息、當日實錄、
以及發出去的兩篇文章裡都說它「關閉時會收掉」。
測量回報:呼叫點零個。
保留 Child 那半落地了,收掉那半從來沒接上。而守衛本身就是空綠:
assert!(src.contains("pub fn stop_mdns_advertising"), ...);
它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它——而那正是實際出貨的狀態。
能力不是行為。
實機證據:那台機器上有三天前(8/26、8/27)開始廣播的 dns-sd,到今天還活著。

母規則我用了六天:任何檢查先示範它會紅才算數。今天學到它還有第二種用途。
修完串流成本之後,我跑了六條紅證,五條精準命中。第六條——把 cli_session
回傳點改回丟棄 usage——每一條測試都還是綠的。
那不是空綠。那是那個位置根本沒有東西守著。 紅證沒有證明檢查有效,
它指出了哪裡從來沒有檢查。
同一天還有一次更繞的:停滯測試的紅證是「把逾時改回寫死 30 秒」,結果測試照樣綠。
我以為抓到空綠,細看才發現——「寫死 30 秒」根本不是缺陷,它只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeout 從判定裡拿掉。
紅證選錯標的,本身也是一種空綠。
前一天收工的帳很漂亮。然後我量了一件從沒量過的事:
~/.local/bin/spectyn 0.6.0 (c2bf11e6fd+, built 2026-08-28) ← PATH 上贏的
~/.cargo/bin/spectyn 0.6.0 (c2bf11e6fd+, built 2026-08-28)
~/.spectyn-mesh/bin/ 不存在(install.sh 的正式目標)
repo HEAD c342076f
兩份獨立的複本、內容相同、都停在三天前。而 ~/.local/bin/spectyn
本來應該是 symlink —— install.sh 用 ln -sf 建它,指向~/.spectyn-mesh/bin/spectyn。現在它是一般檔案,而它該指向的目標不存在。
這台機器的安裝狀態不符合任何一支安裝器的模型。
而這正是前一天的測量預測過的殘留缺陷:cluster upgrade 走的 handler 沒有
canonicalize current_exe(),所以會把 symlink 換成一般檔案,同時install.sh 的真正目標保持舊的。預測的形狀,和機器上看到的形狀一致。
修法是讓 doctor 把它說出來 —— 一個純函式決定「有沒有話可說」:
執行檔
✓ version: spectyn 0.6.0 (52bfc1325a+, macos-aarch64, built 2026-08-31)
✓ path: …/core/target/debug/spectyn
⚠ provenance: 2 spectyn binaries on PATH — the first one wins:
→ /Users/markl/.local/bin/spectyn
/Users/markl/.cargo/bin/spectyn
三條刻意的沉默:不在 repo 裡不比對(沒有東西可比)、不是這個專案的 repo
不比對(它的 HEAD 與這支 binary 無關)、重複的 PATH 條目不算第二份安裝 ——
操作者的 PATH 裡 ~/.local/bin 出現三次,報「3 份安裝」會是它自己的小謊。

前一天 Phase 4 的三條出口條件都打了勾。支撐它們的是:一條直接呼叫 slash
handler 的單元測試、一條雜湊函式跟自己比的單元測試、一條 grep 原始碼
找呼叫點的守衛。
沒有任何一條啟動過出貨的 binary、做那件事、然後看結果。
這是這個專案自己那句話指回自己身上:沒有跑過的路,不算路。
走完之後,第一件事是我的測試錯了。第二條(兩介面同一個 id)一跑就紅:
REPL said pm_3fa52f4dd
TUI said pm_e9cf42f9e
看起來像前一天的統一沒生效。但前綴是對的,只有雜湊不同 —— 它們在算不同的目錄。PtyRun::spawn 有 cmd.cwd(home),把 TUI 放在沙箱目錄;而我的 REPL 用管線驅動,
繼承的是測試行程的 cwd。兩個不同的目錄,本來就該算出不同的 id —— 產品是對的。
差一點就報了一個假的產品缺陷。而且形狀很熟:一個看起來像證據的紅燈,
證明的其實是別的東西。

service uninstall 會跑 launchctl bootout 並刪 ~/Library/LaunchAgents 裡的檔案。
端對端測試看起來就等於要卸掉操作者自己的服務 —— 所以前一天這條是讀程式碼打的勾。
兩件事讓它其實可以走,而兩件事都得先驗證:
一、先證明沙箱真的是沙箱。 plist 路徑來自 dirs::home_dir(),整個策略靠
「改 $HOME 會移動那個目錄」。而在 macOS 上這不是安全假設 —— 前一天才因為NSHomeDirectory() 不理會 $HOME,一個以為自己被隔離的測試其實在讀操作者的
真實設定。所以先寫一條單獨的測試證明它,而且它自己的失敗訊息就寫著:
在這條通過之前,不要走安裝路徑。
二、假的 launchctl。 程式碼呼叫的是 Command::new("launchctl") —— 走 PATH
查找。把一個會記錄呼叫的替身放在 PATH 前面,就能驅動真正的程式碼路徑,
而操作者的 launchd 從頭到尾沒有被定址。
走出來的序列對兩個 label 各做 print → bootout → print,這正是
「遷移過的機器」那句話的重點:這台 Mac 跑的是改名前的 label,只 bootout 新的
等於什麼都沒動,job 每次登入照樣復活,而指令還是印 ✓。
而且第一次手動跑時,我那個粗糙的替身永遠回 exit 0,結果指令正確地判定
「Uninstall INCOMPLETE」並 exit 1 —— 它真的在重新查證,不是相信 bootout 的退出碼。
「bootout 要在刪 plist 之前」那條,第一版寫成「整份記錄裡最後一個 print
排在第一個 bootout 之後」。
紅證把驗證那次查詢整個拿掉 —— 測試照樣綠。
因為有兩個 label:第二個 label 的開場 print 本來就排在第一個 label 的bootout 後面。斷言被另一個 label 的存在滿足了,跟它要證明的行為無關。
改成逐 label 檢查:對每一個 label,它自己的 bootout 之後必須還有一次 print。
再跑紅證,紅。
這幾天同一個形狀已經第四次:一條測試的名字承諾一件事,而滿足它的是另一件事。
每一次都是紅證抓的,一次都不是綠燈抓的。
$HOME 有沒有用、launchctl 怎麼被呼叫)一驗證,原本以為不可能的路就通了。以下是這兩天的實錄,原文按時序未改。
這一階段做法換了:先用 14 個 agent 平行測量七項現況,再動手。
每一項一個唯讀 agent(不准改檔、不准碰真實資料根、不准在區網放 daemon),
發現再交給一組獨立的懷疑者駁倒。17 分鐘。
值得的地方不是省時間,是兩項被翻案——照題目字面去修,一項會修一個沒壞的東西,
另一項會開一個安全洞。
量測 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型、
線上同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。
| 路徑 | 結果 |
|---|---|
非串流(serve → run_tracked) |
cost_usd: 7.5,ledger 有寫 |
串流(run_with_callbacks,TUI 與 exec 走的那條) |
$0.00,而且連 ledger 檔都沒產生 |
也就是說 CostTracker、價格表、持久化、四處顯示全部是對的。
照題目去「修成本追蹤」,會去修一個沒壞的東西。
壞的只有一條鏈,而且斷在兩個地方:
call_with_streaming 的每個 synthetic return 都寫死 "usage": {},choices / delta / type ——choices: [],所以它到了線上,被走過去了。stream_options.include_usage 只在有工具時才要。純聊天串流從一開始就成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,是根本沒呼叫:
連檔案都不會被建立。
修的時候還撞到第三個:cli_session 那條(codex/opencode/agy)回的是{input_tokens, output_tokens},而成本讀的是 {prompt_tokens, completion_tokens}。
兩種拼法、一個讀取端 —— 跟這一整天其他缺陷同一個形狀。所以光把Ok((text, _usage)) 改成不丟掉還不夠,得先正規化。收斂成一個normalize_usage(),是唯一調和它們的地方。
它現在拒絕,而那是刻意的:外部複查後加的 fail-closed 閘。照字面「修好」——
把 guard 刪掉——會把 22 條未驗證的 /api 與 /rpc 路由(/api/todos、/api/sessions、/api/cost、/api/governance/log、/rpc/peers……)publish
到 tailnet 或區網上。
列為新裁定點,不猜。可選:維持拒絕但把訊息改成可行動的、加驗證後才允許非 loopback、
或明確的 --unsafe-lan 旗標加大聲警告。這是安全決策,不是我該替操作者做的。
第六項「mDNS 廣播改為明示,且 advertiser 關閉時要回收」被判只做一半。
stop_mdns_advertising() 早上寫好了。呼叫點零個。
commit 訊息寫著「保留 Child 並在關閉時收掉」——保留那半落地了,收掉那半
從來沒接上。而 Day 6 實錄、以及今天發出去的兩篇文章,都重複了那個說法。
更難看的是守衛本身就是空綠:
assert!(src.contains("pub fn stop_mdns_advertising"), ...);
它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它 —— 而那正是實際出貨的狀態。
能力不是行為。
實機證據:這台機器上有 8/26、8/27 起就在廣播的 dns-sd -R spectyn-spectyn,
活到 8/30 還在。
修法:接進 serve 的關閉序列(兩條分支都經過,且在 serve_result? 之前,
所以 serve 出錯也不會跳過),守衛改成要求 spectyn.rs 有非註解的呼叫。
紅證兩條:拿掉呼叫會紅、只留被註解掉的呼叫也會紅。
TUI 的 /tasks 會 Tab 補全,然後回「unknown command」。而 SLASH_COMMANDS_TUI
正上方的註解白紙黑字寫著:
Keep this list in sync with the match arms in handle_tui_slash. Adding a
command here without a handler makes Tab completion suggest it, then submit
it, then have the handler bail with 'unknown command' — which is the worst of
both worlds.
/tasks 是唯一違反它自己這條註解的項目。
而既有的 ratchet 反過來保護這個壞狀態:它斷言補全清單等於註冊表,
卻從不斷言 arm 存在。所以測試套件保證了 /tasks 會被補全,而沒有任何東西
保證它會被派發。
新守衛切出 handle_tui_slash 的函式體,對每個 TUI 指令斷言帶引號的字面值。
帶引號是刻意的:contains("/task") 會被 "/tasks" 滿足 ——
那種斷言會被它該抓的東西滿足。紅證兩條:拿掉 arm 紅、把 arm 改名成前綴 /task 也紅。
session id 不是一個 bug,是三個疊在一起:
| tui.rs | bin/spectyn.rs | |
|---|---|---|
| 前綴 | cwd- |
pm_ |
| 雜湊的東西 | Path(cwd.hash()) |
字串(cwd.to_string_lossy().hash()) |
| 寬度 | {:x} |
{:016x} |
Path 和 str 的 Hash 實作不同,所以只對齊前綴會看起來修好了而仍然是壞的。
而兩個介面寫進同一個命名空間。實測在操作者真實的 store 上:同一個 repo 目錄
有兩個各自長大的檔 —— cwd-f09d… 20 行、pm_6bbe… 88 行。在 REPL 停下的地方
打開 TUI,歷史就是不在那裡;而模型從該介面挑到的那個檔被餵得好好的。
採 pm_ 格式,因為既有歷史都在那(36 個檔 vs 2 個)。舊 id 的 session 不會失聯 ——/resume 走前綴比對 —— 只是不再自動被撿起。
重啟、/resume、/fork,三個都是空白畫面。而下一輪把完整歷史送給模型。
量測 agent 把這件事用實驗證明了,不是讀程式碼推的:它架了一個假的
OpenAI-compat provider,攔下外送的 POST body —— 裡面有 8 則訊息,含四個
marker 字串,逐字都在。同一輪,螢幕上一個都沒有。
所以 agent 表現得像記得一段操作者看不見的對話,而空 session 和 88 輪的 session
長得一模一樣。
共用一個 replay_view() 做「該顯示什麼」的決定(過濾 tool 流量與空白、
取最後 20 則、回報略過幾則),兩個介面都讀它 —— 否則它們會對同一個 session
顯示不同的過去。
ws://auto:PORT/wsbanner 印的是設定檔裡那個字,而 host 預設就是字面字串 "auto",從不被重新賦值。
量測 agent 直接用 curl 證明:
$ curl -sS -m 5 "http://auto:39873/healthz"
curl: (6) Could not resolve host: auto
$ curl -sS -m 5 "http://127.0.0.1:39873/healthz"
ok
同一個行程,一個死一個活。從 banner 複製一行出去的人,拿到的是死的端點。
而且還有第二處:spectyn coordinator 印 listen : http://auto:PORT。
修的時候我自己引入了一個問題,被守衛抓到:resolve_serve_bind_hosts 會在
stderr 印警告,而 serve 路徑呼叫了三次(LAN 閘、banner、bind)——
那句「只綁 loopback、對所有 peer 隱形」的警告會印三次。改成整條路徑解析一次,
banner 印的就是 bind 用的那一份。
banner 那條的守衛我改寫過一次。第一版數「視窗內呼叫 resolve_serve_bind_hosts
幾次」——而修好之後視窗內本來就只剩一次,所以把 banner 改回獨立解析,測試照樣綠。
它量的是代理指標。改成直接斷言 banner_host(&host, &bind_hosts) ——
把 banner 綁在 bind 用的那個值上 —— 才會紅。
cost 那批六條紅證,五條精準命中,第六條沒有:我把 cli_session 回傳點改回丟棄 usage,
每一條測試都還是綠的。
不是空綠,是那個位置根本沒有東西守著。那條路要 shell out 到外部 CLI,
CI 上不存在,所以沒有 hermetic 的驅動方式。補了一條 ratchet 並老實標明它是 ratchet:
斷言那個 arm 沒有 Ok((text, _usage))、而且有 normalize_usage(&usage)。
紅證的用途不只是「證明這條檢查有效」,還包括指出哪裡根本沒有檢查。
今天全量測試跑了幾次,每次都覺得「卡住了」。實際量:
編譯 7m39s
--list ~45m ← 這裡
實跑 98s
nextest 在真正跑測試之前,會對每個測試 binary 執行一次 --list。而這個專案有
180 個測試 binary。列舉階段每個行程都是 0% CPU 在等 —— 我一度以為是磁碟 IO。
ps 給了答案:
%CPU COMMAND
37.3 /usr/libexec/syspolicyd
macOS 的 Gatekeeper 正在對每個剛連結出來的 binary 做首次執行評估。
180 個 × 十幾秒 ≈ 45 分鐘。這也解釋了為什麼列舉之後真正跑測試只要 98 秒:
那時每個 binary 都已經評估過了。
換句話說,一次完整重建之後的全量測試,有 95% 的時間不在編譯也不在測試。
修法在系統設定那邊(把終端機加進「開發者工具」豁免),不是我能替操作者按的 ——
但值得記下來,因為它比今天早上那個 55 GB 的建置稅貴得多,而且完全隱形:
沒有任何輸出會告訴你它在等 Gatekeeper。
同樣先平行測量五項,再動手。五項裡兩項早就修好了、一項題目本身是錯的。
| 項目 | 裁決 |
|---|---|
self-update 有可用來源 |
確認,而且比題目更糟 |
| macOS 上換對目錄 | 早就修好了(commit 40219efe) |
service uninstall 移得掉舊 label |
早就修好了 |
| 「唯一碰精靈的測試斷言它不要跑」 | 題目寫錯——今天已經有六個檔在測,其中一個在 PTY 上真的驅動它 |
| 網路中途斷線把半截答案當完整回傳 | 確認,而且機器可讀的那條通道更糟 |
如果照佇列字面往下做,五項有三項會做白工或做錯。
spectyn exec --json 對一個被砍斷的串流說「done」量測 agent 架了一個假的 OpenAI-compat SSE 伺服器,兩次執行只差在伺服器怎麼結束:
乾淨: {"type":"done","output":"…1) open the file, 2) edit the line, 3) save."} exit 0
砍斷: {"type":"done","output":"…1) open the file, 2) edit the li"} exit 0
↑ 斷在字中間
stdout、stderr、退出碼逐位元無法區分,而且兩次 stderr 都是空的。
同一個埠上 curl 說:
curl: (56) Recv failure: Connection reset by peer
curl 說 56,spectyn 說 exit 0,還發了一個 done 事件。
一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件。
原因在五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看的時候,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件了。唯一的守衛是if full_content.is_empty(),一個位元組的內容就把它解除了。
一、「沒有 [DONE] 就算斷線」會弄壞每一次 Anthropic 執行。
Anthropic 的串流就是靠 body EOF 結束的——這個迴圈根本不解析 message_stop。
所以對那個格式而言,健康的結束和被關掉的 socket 逐位元相同。
所以我只標記兩個無歧義的死法:傳輸錯誤、閒置逾時。EOF 刻意不標,
並在程式碼裡把理由寫清楚。兩次量到的失敗都屬於這兩種。
二、不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈和
多輪 agent 迴圈裡面。如果那一輪已經發出了 tool_calls,重跑會再執行一次file_write 和 shell。一次網路抖動不可以變成操作者磁碟上的重複副作用。
所以是「標記」不是「重試」:走既有的 Notice 通道(detect_truncation_notice
早就在用它報 max_tokens 截斷),再把旗標帶到 AgentResult 與 Done 事件上,
讓 exec 拒絕回 exit 0。內容照樣印出來——那是真的工作成果——只是退出碼
不再宣稱它完成了。
三、這段程式碼就是已安裝的 daemon 在跑的。 所以整個過程沒有重建到已安裝的
產物、沒有跑 self-update、沒有碰真實 prefix。
「wifi 斷掉但 socket 沒關」是最常見的一種,而它走的是 30 秒閒置逾時——
寫死的。沒有測試付得起半分鐘,所以行為最誤導的那條路完全沒有覆蓋。
改成可設定之後才寫得出測試。
停滯那條測試第一版斷言的是「truncated 為真」。紅證把逾時改回寫死 30 秒,
測試照樣綠——只是花了 20 秒。
因為我的假伺服器睡 20 秒之後執行緒回到 accept 迴圈、socket 被丟掉,
所以它是從傳輸錯誤那條路過的,只是晚了 20 秒。名字說它在測逾時,
實際上它在測另一個機制。
改成斷言理由(notice 裡要出現 "no data for"),並讓假伺服器把連線
一直握著,逾時才是唯一的出路。
然後我發現「寫死 30 秒」這個變異本來就不該紅——那不是缺陷,只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeout 從 is_truncation 拿掉:
只有停滯那條紅,另外兩條照常綠。紅證選錯標的,本身也是一種空綠。
這一項確認壞掉,而且比題目寫的更糟——有三套互相矛盾的命名:
| 誰 | 要的檔名 |
|---|---|
spectyn.rs / install.sh |
spectyn-aarch64-apple-darwin |
| 線上 Worker 的 manifest | spectyn-darwin-arm64 |
serve.rs 的 allowlist |
兩個都不是 |
線上伺服器把兩種失敗分得很清楚:
GET /dist/spectyn-aarch64-apple-darwin → 404 {"error":"unknown binary …"}
GET /dist/spectyn-darwin-arm64 → 503 {"error":"binary … missing in R2 bucket"}
也就是說,就算操作者去把 R2 填滿,CLI 要的那個名字仍然會 404。
而且 --dry-run 是假綠:它 exit 0 說「would download but not install」,
卻從來不去探那個來源——任何人用 --dry-run 驗證修法,都會在一個仍然壞掉的
來源上拿到通過。
危險提示也很具體:別把 https 守衛放寬、別讓 sha256 sidecar 變成可選——
那些是 plain-http fallback 上唯一擋住 MITM 換掉 binary 的東西。來源是空的,
守衛是對的。
命名收斂可以在程式碼裡做完;填 R2 需要 wrangler login,是操作者的事。
前一天收工時的帳很漂亮:六個階段、+891 條測試、全量 3,528 全綠、四個 commit。
然後我量了一件從來沒量過的事 —— 操作者實際會跑的那支 binary 是哪一版:
~/.local/bin/spectyn 0.6.0 (c2bf11e6fd+, built 2026-08-28)
~/.cargo/bin/spectyn 0.6.0 (c2bf11e6fd+, built 2026-08-28)
repo HEAD c342076f
Day 06 的每一個修法,一項都不在他實際會跑的那支裡。
那些修掉的謊 —— exit 0 卻什麼都沒做、doctor 謊報沒有身分、--config 被靜默忽略、
三個介面對同一個 Ask 三種意思、半截答案被當成完成 —— 對操作者的機器來說,
今天仍然全部還在。
而且是兩份,內容相同,都停在 8/28。~/.local/bin 在他的 PATH 裡出現三次。
這是這個專案自己那句話的最新一案:沒有跑過的路,不算路 ——
只是這次沒跑過的是「把成果交出去」。
也沒有任何東西會告訴他這件事:spectyn --version 印得出 commit,
但沒有人拿它跟 repo 比對過。
| 路徑 | 實況 |
|---|---|
~/.local/bin/spectyn |
一般檔案,PATH 上贏的那支 |
~/.cargo/bin/spectyn |
一般檔案,另一個 inode,內容相同 |
~/.spectyn-mesh/bin/ |
不存在 |
~/Library/Application Support/spectyn-mesh/bin/ |
存在但空的 |
兩份獨立的複本、內容相同、都停在 8/28。而 ~/.local/bin/spectyn
本來應該是 symlink —— scripts/install.sh:265 用 ln -sf 建它,指向~/.spectyn-mesh/bin/spectyn。現在它是一般檔案,而它該指向的目標不存在。
也就是說:這台機器的安裝狀態不符合任何一支安裝器的模型。
而且這正是 Phase 5 測量預測過的殘留缺陷 —— cluster upgrade 走serve.rs 的 handler,那裡的 current_exe() 沒有 canonicalize,
所以會把 symlink 換成一般檔案,同時 install.sh 的真正目標保持舊的。
預測的形狀和機器上看到的形狀一致。
doctor 現在會說出來原本 doctor 印版本、印 commit、印路徑 —— 然後沒有拿其中任何一項跟任何東西比對。
加上 provenance 之後:
執行檔
✓ version: spectyn 0.6.0 (52bfc1325a+, macos-aarch64, built 2026-08-31)
✓ path: …/core/target/debug/spectyn
⚠ provenance: 2 spectyn binaries on PATH — the first one wins:
→ /Users/markl/.local/bin/spectyn
/Users/markl/.cargo/bin/spectyn
判定邏輯是純函式(diagnostics::provenance_lines),只在有話可說時出聲:
單一安裝、且與所在 repo 同步 → 一行都不印。
三條刻意的沉默:不在 repo 裡不比對(沒有東西可比)、不是這個專案的 repo 不比對
(它的 HEAD 與這支 binary 無關)、重複的 PATH 條目不算第二份安裝
(操作者的 PATH 裡 ~/.local/bin 出現三次,報「3 份安裝」會是它自己的小謊)。
端對端測試三條:在一個臨時 git repo 裡跑真的 binary,斷言它說得出落後、
且在無可比對時保持安靜。紅證五條全數命中。
昨天 Phase 4 收工時三條出口條件都打了勾:
重啟看得到真逐字稿;兩介面 id 一致;關閉後無殘留 advertiser
而支撐它們的是:一條直接呼叫 slash handler 的單元測試、一條雜湊函式跟自己比
的單元測試、和一條 grep 原始碼找呼叫點的守衛。
沒有任何一條啟動過出貨的 binary、做那件事、然後看結果。
這正是這個專案自己那句話指回自己身上:沒有跑過的路,不算路。
第二條(兩介面同一個 id)一跑就紅:
REPL said pm_3fa52f4dd
TUI said pm_e9cf42f9e
看起來像昨天的統一沒生效。但前綴是對的 —— pm_ —— 只有雜湊不同,
也就是說它們在算不同的目錄。
查下去:PtyRun::spawn 有 cmd.cwd(home),把 TUI 放在沙箱目錄跑;
而我的 REPL 用管線驅動,繼承的是測試行程的 cwd(core/)。
兩個不同的目錄,本來就該算出不同的 id —— 產品是對的,測試是錯的。
差一點就報了一個假的產品缺陷。而且形狀很熟:一個看起來像證據的紅燈,
證明的其實是別的東西。
補上 current_dir(home) 之後三條全綠。
「關閉後無殘留 advertiser」的真實 advertiser 是 dns-sd,而啟動一個
就是在操作者的網路上放廣播 —— 這個專案自己的規則禁止測試這樣做。
所以拆成兩半:機制(註冊表 + 殺掉)用一個替身子行程端對端測;
LAN 廣播那半明說不走,理由寫在測試的文件註解裡。
替身測試證明的是原本沒有任何東西證明的事:回收器真的會殺掉它註冊的東西,
而且第二次呼叫時註冊表是空的(關閉路徑有兩條分支都會經過它)。
原本的來源守衛留著 —— 它擋的是另一件事:那個函式曾經零個呼叫點,
而 commit 訊息、實錄、兩篇發表的文章都說它接上了。
守衛證明呼叫存在,替身測試證明呼叫有用。 兩條都需要。
uninstall 之後 plist 不存在、行程不再重生
昨天這條打勾的依據是讀程式碼,因為走它看起來不可能:service uninstall 會跑launchctl bootout gui/<uid>/<label> 並刪 ~/Library/LaunchAgents 裡的檔案 ——
端對端測試看起來就等於要卸掉操作者自己的服務。
有兩件事讓它其實可以走,而兩件事都得先驗證:
plist 路徑來自 dirs::home_dir()。整個沙箱策略都靠「改 $HOME 會移動那個目錄」
這個假設 —— 而在 macOS 上這不是安全的假設:今天稍早才因為NSHomeDirectory() 不理會 $HOME,一個以為自己被隔離的測試其實在讀操作者的真實
設定檔。
所以先寫一條單獨的測試證明 dirs::home_dir() 會跟隨 $HOME,而且它自己的
失敗訊息就寫著:在這條通過之前,不要走安裝路徑。它通過了。
程式碼呼叫的是 Command::new("launchctl") —— 走 PATH 查找。所以把一個會記錄
呼叫的替身放在 PATH 前面,就能驅動真正的程式碼路徑,而操作者的 launchd
從頭到尾沒有被定址。
走出來的序列正是這一條要證明的:
print gui/501/ai.spectynmesh.serve
bootout gui/501/ai.spectynmesh.serve
print gui/501/ai.spectynmesh.serve ← 驗證,不是相信
print gui/501/ai.phantommesh.serve
bootout gui/501/ai.phantommesh.serve
print gui/501/ai.phantommesh.serve
兩個 label 都掃,這正是「遷移過的機器」那句話的重點 —— 這台 Mac 上跑的是改名前的ai.phantommesh.serve,只 bootout 新 label 等於什麼都沒動,job 每次登入照樣復活,
而指令還是印 ✓。
而且第一次手動跑的時候,我那個粗糙的替身永遠回 exit 0,結果指令正確地判定
「Uninstall INCOMPLETE」並 exit 1 —— 它真的有在重新查證,而不是相信 bootout 的
退出碼。
「bootout 要在刪 plist 之前」那條,第一版寫成「整份記錄裡最後一個 print
排在第一個 bootout 之後」。
紅證把驗證那次查詢整個拿掉 —— 測試照樣綠。
因為有兩個 label:第二個 label 的開場 print 本來就排在第一個 label 的bootout 後面。斷言被另一個 label 的存在滿足了,跟它要證明的行為無關。
改成逐 label 檢查:對每一個 label,它自己的 bootout 之後必須還有一次print。再跑紅證,紅。
這是今天第二次、也是這個專案這幾天第四次同一個形狀:一條測試的名字承諾一件事,
而滿足它的是另一件事。 每一次都是紅證抓的。
第一版測試把 PATH 縮成只剩替身加 /usr/bin:/bin,結果 60 秒逾時、launchctl 零次呼叫 —— 看起來像「uninstall 根本沒跑」。實際上是啟動時某個探測
在最小 PATH 下卡住。改成前置替身而不是取代 PATH 就好了。
差一點又報一個假的產品缺陷 —— 今天第二次。